Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7462cb05ec | ||
|
|
e3c81fd304 | ||
|
|
50f3d51946 | ||
|
|
1e85b4d05f | ||
|
|
cb9e51f08d | ||
|
|
74deabd630 |
No files matched your search
@@ -66,7 +66,13 @@ env:
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
#
|
||||
# SUSPEND skips needrestart entirely. Mode `a` (restart automatically) is not
|
||||
# safe: on a host-mode runner it restarted act-runner.service mid-job and
|
||||
# killed the job. Mode `l` (list only) is a fallback for a needrestart that
|
||||
# ignores SUSPEND; it never prompts and never restarts anything.
|
||||
NEEDRESTART_SUSPEND: "1"
|
||||
NEEDRESTART_MODE: l
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -272,7 +278,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -283,8 +289,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
@@ -368,7 +374,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -391,8 +397,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
|
||||
@@ -22,7 +22,13 @@ env:
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
#
|
||||
# SUSPEND skips needrestart entirely. Mode `a` (restart automatically) is not
|
||||
# safe: on a host-mode runner it restarted act-runner.service mid-job and
|
||||
# killed the job. Mode `l` (list only) is a fallback for a needrestart that
|
||||
# ignores SUSPEND; it never prompts and never restarts anything.
|
||||
NEEDRESTART_SUSPEND: "1"
|
||||
NEEDRESTART_MODE: l
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -125,7 +131,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
echo "Node.js at: $(which node)"
|
||||
@@ -147,8 +153,8 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_SUSPEND=1 NEEDRESTART_MODE=l DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+569
-25
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -0,0 +1,842 @@
|
||||
//! 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).
|
||||
//! - **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.
|
||||
|
||||
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);
|
||||
/// How often an Up tunnel sends traffic of its own. Session Manager ends a
|
||||
/// session after 20 minutes with no data (its default `idleSessionTimeout`;
|
||||
/// measured: a silent tunnel was cut at 19.7 min), and with it every TCP
|
||||
/// connection inside — an idle terminal tab included.
|
||||
const KEEPALIVE_INTERVAL: Duration = if cfg!(test) {
|
||||
Duration::from_millis(200)
|
||||
} else {
|
||||
Duration::from_secs(5 * 60)
|
||||
};
|
||||
const KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
// ── 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:<cluster>_<taskId>_<runtimeId>`, 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<Self, String> {
|
||||
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<PathBuf, String> {
|
||||
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::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let installed: Vec<PathBuf> = 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<Mutex<Vec<String>>>);
|
||||
|
||||
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<String> {
|
||||
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::<Vec<_>>()
|
||||
.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<String>,
|
||||
env: Vec<(String, Zeroizing<String>)>,
|
||||
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<Running, String> {
|
||||
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<watch::Sender<bool>>,
|
||||
) {
|
||||
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<Output = Result<Running, String>> + Send;
|
||||
fn terminate(&self, session_id: String) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
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<Option<aws_sdk_ssm::config::Credentials>>,
|
||||
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<Running, String> {
|
||||
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();
|
||||
// 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,
|
||||
"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(self.plugin_credentials().await);
|
||||
|
||||
let launch = PluginLaunch {
|
||||
program: self.plugin.clone(),
|
||||
// <response env var> <region> StartSession <profile> <request> <endpoint>:
|
||||
// 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) => {
|
||||
guard.disarm();
|
||||
Ok(r)
|
||||
}
|
||||
Err(e) => {
|
||||
// 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) {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String>)> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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<u16, String> {
|
||||
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<TunnelState>,
|
||||
plugin_pid: watch::Receiver<Option<u32>>,
|
||||
supervisor: Option<tokio::task::JoinHandle<()>>,
|
||||
_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<Self, String> {
|
||||
let region = sdk
|
||||
.region()
|
||||
.ok_or("No AWS region is configured for this connection")?
|
||||
.to_string();
|
||||
let opener = SsmOpener {
|
||||
sdk: sdk.clone(),
|
||||
plugin_creds: Default::default(),
|
||||
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<O: SessionOpener>(opener: O) -> Result<Self, String> {
|
||||
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<O: SessionOpener>(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<String>) -> AgentEndpoint {
|
||||
AgentEndpoint::loopback(self.local_port, token)
|
||||
}
|
||||
|
||||
pub fn state(&self) -> watch::Receiver<TunnelState> {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
/// The running plugin's process id, if one is running.
|
||||
pub fn plugin_pid(&self) -> Option<u32> {
|
||||
*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<O: SessionOpener>(
|
||||
opener: O,
|
||||
local_port: u16,
|
||||
mut running: Running,
|
||||
state: watch::Sender<TunnelState>,
|
||||
pid: watch::Sender<Option<u32>>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
loop {
|
||||
// ── Up: wait for the plugin to exit, or for close; keep it busy ──
|
||||
let mut keepalive = tokio::time::interval_at(
|
||||
tokio::time::Instant::now() + KEEPALIVE_INTERVAL,
|
||||
KEEPALIVE_INTERVAL,
|
||||
);
|
||||
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let exited = loop {
|
||||
tokio::select! {
|
||||
s = running.child.wait() => break s,
|
||||
_ = keepalive.tick() => {
|
||||
tokio::spawn(keepalive_probe(local_port));
|
||||
}
|
||||
_ = 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;
|
||||
// Both halves: SSM's own reason is in the output ("timed out due to
|
||||
// inactivity"), but after a kill the last line is just whatever the
|
||||
// plugin printed before, so the exit status has to be there too.
|
||||
let mut last_error = match running.output.last() {
|
||||
Some(line) => format!("the SSM session ended ({}; last output: {line})", describe_exit(exited)),
|
||||
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)");
|
||||
}
|
||||
}
|
||||
|
||||
/// One request through the tunnel so SSM sees data: an *unauthenticated*
|
||||
/// `GET /v1/health`. The agent answers `401` before it counts anything as
|
||||
/// activity, so this keeps the session alive without keeping the workspace
|
||||
/// "in use" for idle auto-stop — and it needs no token here.
|
||||
async fn keepalive_probe(local_port: u16) {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let probe = async {
|
||||
let mut s = tokio::net::TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
s.write_all(b"GET /v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
|
||||
.await?;
|
||||
let mut sink = [0u8; 512];
|
||||
while s.read(&mut sink).await? > 0 {}
|
||||
Ok::<_, std::io::Error>(())
|
||||
};
|
||||
match tokio::time::timeout(KEEPALIVE_TIMEOUT, probe).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => log::debug!("SSM keepalive on 127.0.0.1:{local_port}: {e}"),
|
||||
Err(_) => log::debug!("SSM keepalive on 127.0.0.1:{local_port}: timed out"),
|
||||
}
|
||||
}
|
||||
|
||||
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<std::process::ExitStatus>) -> String {
|
||||
match s {
|
||||
Ok(s) => format!("plugin {s}"),
|
||||
Err(e) => format!("plugin wait failed: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
@@ -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<String, String> {
|
||||
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<u8> = (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();
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
/// Runs the script as `/bin/sh <script>`: the script is only ever read,
|
||||
/// never exec'd, so a parallel test's fork holding it open for writing
|
||||
/// for a moment cannot fail the spawn with ETXTBSY ("Text file busy").
|
||||
fn launch(script: PathBuf, secret: &str) -> PluginLaunch {
|
||||
PluginLaunch {
|
||||
program: "/bin/sh".into(),
|
||||
args: vec![
|
||||
script.display().to_string(),
|
||||
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<Mutex<Vec<u16>>>,
|
||||
terminated: Arc<Mutex<Vec<String>>>,
|
||||
opened: Arc<AtomicU32>,
|
||||
/// Fail opens whose 1-based number is in here, with this message.
|
||||
fail: Vec<(u32, &'static str)>,
|
||||
/// Connections made to the local port (the fake plugin listens on
|
||||
/// nothing, so the opener stands in for its listener).
|
||||
connections: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
impl FakeOpener {
|
||||
fn new(plugin: PathBuf) -> Self {
|
||||
Self {
|
||||
plugin,
|
||||
ports: Default::default(),
|
||||
terminated: Default::default(),
|
||||
opened: Default::default(),
|
||||
fail: vec![],
|
||||
connections: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionOpener for FakeOpener {
|
||||
async fn open(&self, local_port: u16) -> Result<Running, String> {
|
||||
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());
|
||||
}
|
||||
if let Ok(listener) = tokio::net::TcpListener::bind(("127.0.0.1", local_port)).await {
|
||||
let count = self.connections.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((sock, _)) = listener.accept().await {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
drop(sock);
|
||||
}
|
||||
});
|
||||
}
|
||||
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<TunnelState>, 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 an_up_tunnel_keeps_itself_busy() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30"));
|
||||
let opener = FakeOpener::new(p);
|
||||
let connections = opener.connections.clone();
|
||||
let pf = PortForward::open_with(opener).await.expect("open");
|
||||
tokio::time::sleep(KEEPALIVE_INTERVAL * 5 + KEEPALIVE_INTERVAL / 2).await;
|
||||
let n = connections.load(Ordering::SeqCst);
|
||||
assert!((3..=6).contains(&n), "{n} keepalives in 5.5 intervals");
|
||||
pf.close().await;
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
/// A cancelled open (its future dropped after `StartSession` succeeded) must
|
||||
/// still end the session: the guard's drop sends `TerminateSession`. Checked
|
||||
/// against a fake SSM endpoint that records what it is asked.
|
||||
#[tokio::test]
|
||||
async fn a_dropped_open_still_ends_its_session() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let tx = seen_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
let mut req = Vec::new();
|
||||
// Headers and the small JSON body arrive together here.
|
||||
while let Ok(n) = sock.read(&mut buf).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
req.extend_from_slice(&buf[..n]);
|
||||
let text = String::from_utf8_lossy(&req);
|
||||
if text.contains("SessionId") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = tx.send(String::from_utf8_lossy(&req).into_owned());
|
||||
let body = r#"{"SessionId":"s-dropped"}"#;
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/x-amz-json-1.1\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let conf = aws_sdk_ssm::Config::builder()
|
||||
.behavior_version(aws_sdk_ssm::config::BehaviorVersion::latest())
|
||||
.region(aws_sdk_ssm::config::Region::new("us-west-2"))
|
||||
.credentials_provider(aws_sdk_ssm::config::Credentials::new("AKIDTEST", "secret", None, None, "test"))
|
||||
.endpoint_url(format!("http://127.0.0.1:{port}"))
|
||||
.build();
|
||||
let guard = SessionGuard {
|
||||
ssm: aws_sdk_ssm::Client::from_conf(conf),
|
||||
session_id: Some("s-dropped".into()),
|
||||
};
|
||||
drop(guard);
|
||||
let req = tokio::time::timeout(Duration::from_secs(10), seen_rx.recv())
|
||||
.await
|
||||
.expect("no TerminateSession arrived")
|
||||
.unwrap();
|
||||
assert!(req.contains("AmazonSSM.TerminateSession"), "{req}");
|
||||
assert!(req.contains("s-dropped"), "{req}");
|
||||
}
|
||||
|
||||
/// The keepalive is the unauthenticated health probe: some traffic, no token.
|
||||
#[tokio::test]
|
||||
async fn the_keepalive_asks_for_health_without_a_token() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut sock, _) = listener.accept().await.unwrap();
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let n = sock.read(&mut buf).await.unwrap();
|
||||
sock.write_all(b"HTTP/1.1 401 Unauthorized\r\ncontent-length: 0\r\n\r\n").await.unwrap();
|
||||
String::from_utf8_lossy(&buf[..n]).into_owned()
|
||||
});
|
||||
keepalive_probe(port).await;
|
||||
let req = server.await.unwrap();
|
||||
assert!(req.starts_with("GET /v1/health HTTP/1.1\r\n"), "{req}");
|
||||
assert!(!req.to_ascii_lowercase().contains("authorization"), "{req}");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -442,6 +442,29 @@ way.
|
||||
entrypoint. It doesn't depend on the app being open. Because a running scheduler job counts as
|
||||
activity, scheduled tasks keep a workspace alive. The admin's maximum lifetime is a hard cap
|
||||
either way.
|
||||
- **Health:** the task definition carries an ECS container health check against the agent, run
|
||||
inside the container with the token from its tmpfs file, so the token never leaves the task:
|
||||
`curl -sf --max-time 3 -H "Authorization: Bearer $(cat /dev/shm/triple-c/agent-token)"
|
||||
http://127.0.0.1:47900/v1/health` (interval 30 s, retries 3, `startPeriod` 120 s). ECS reports it
|
||||
as the container's and the task's `healthStatus` in `DescribeTasks`, so the same 15 s poll
|
||||
shows *Starting* (`UNKNOWN`), *Ready* (`HEALTHY`) or *Unhealthy* (`UNHEALTHY`) with no
|
||||
connection open. Measured 2026-11-14: `HEALTHY` 105 s after `RunTask`. The app still checks the
|
||||
agent through its own tunnel before it calls a project connected.
|
||||
- **A workspace outlives the app.** Nothing about a task depends on the app or its tunnel:
|
||||
- Quitting or crashing the app ends only the SSM session. Idle auto-stop and the admin's
|
||||
lifetime cap are what stop the task.
|
||||
- The next app (the same machine or another) resolves the task and opens a new tunnel.
|
||||
- Measured 2026-11-14: a background job started through one tunnel kept writing every 5 s,
|
||||
with no gap, after that tunnel's processes were killed with `-9`. A fresh tunnel found it
|
||||
still running, and the agent still reported its exec as running.
|
||||
- What does **not** survive is an attached exec. As with `docker exec`, the agent sends
|
||||
SIGHUP to a tty's process group when its client disconnects. So a terminal tab's `claude`
|
||||
ends with the app, and comes back through `claude --resume`, whose transcripts live on the
|
||||
workspace's storage. Keeping live terminals across an app restart (a detached pty the agent
|
||||
holds for re-attach) is a possible later feature, not v1.
|
||||
- If the app dies without closing its tunnels, the plugin child can outlive it. Its session
|
||||
then carries no keepalive (that lives in the app) and SSM ends it within its 20-minute idle
|
||||
timeout. The host still closes every tunnel on a normal exit.
|
||||
- Logs: `awslogs` driver to the environment's log group, stream `<owner>/<project>/<task>`. The
|
||||
app shows start logs during startup and when a start fails.
|
||||
|
||||
@@ -571,9 +594,30 @@ aws/
|
||||
- ABAC condition keys for each action: ECS `RunTask` tag-on-create, `ExecuteCommand`, and
|
||||
`ssm:StartSession` against ECS targets; EFS `CreateAccessPoint` with request tags; Secrets
|
||||
Manager name-prefix plus tag conditions with `${aws:userid}` for SSO sessions.
|
||||
- That `session-manager-plugin` port forwarding to an ECS target is stable for hours-long
|
||||
sessions, and how quickly it reconnects.
|
||||
- Fargate image-pull time for the sandbox image, and whether SOCI indexes help.
|
||||
- ~~That `session-manager-plugin` port forwarding to an ECS target is stable for hours-long
|
||||
sessions, and how quickly it reconnects.~~ **Measured 2026-11-14** (phase 2b, us-west-2,
|
||||
plugin 1.2.896, Fargate 1 vCPU / 2 GB, `host::ssm::live_tests`):
|
||||
- Opening the port forward takes 0.6-0.7 s. A one-shot exec through it averages 35 ms, against
|
||||
about 1 s for a fresh `execute-command`. A file moves at about 0.75 MB/s each way (4 MiB in
|
||||
5.5 s), which is SSM's own throughput.
|
||||
- When the plugin is killed, the supervisor reopens the session on the same local port in
|
||||
about 0.55 s.
|
||||
- **Session Manager's idle timeout applies to port forwards.** A tunnel with no traffic was cut
|
||||
at 19.7 minutes ("timed out due to inactivity"), and every connection inside it went with it,
|
||||
including a held-open attach (the test's stand-in for an idle terminal tab). The supervisor
|
||||
reconnected at once and requests resumed, but open streams do not survive a reconnect. So an
|
||||
Up tunnel sends an unauthenticated `GET /v1/health` every 5 minutes. The agent answers `401`
|
||||
before it counts activity, so this doesn't defeat idle auto-stop. With it, a 44-minute hold
|
||||
with 22 silent minutes had no reconnect at all, and the held-open attach survived to the end
|
||||
(without it, the same attach died at the 20-minute cut).
|
||||
- Over 60 minutes, no drop happened other than the idle timeout.
|
||||
- Fargate image-pull time for the sandbox image, and whether SOCI indexes help. **Measured
|
||||
2026-11-14:** the 1.1 GB (compressed) amd64 image pulled from public GHCR in 55 s, and the task
|
||||
reached RUNNING about 75 s after `RunTask`. **The entrypoint's `chown -R /home/claude` then took
|
||||
about 5.5 minutes.** On ECS, `/home/claude` is the image's own copy (no volume), so it copied the
|
||||
whole tree up, also costing about 2 GB of ephemeral storage. It now chowns only what isn't
|
||||
already `claude`'s, and the agent answers 83 s after `RunTask`, 4 s after the container starts.
|
||||
SOCI was not tried.
|
||||
- That GHCR pull-through cache works for the public image.
|
||||
- That the `SIGTERM` deadline on Fargate retirement and Spot leaves enough time for a WIP push.
|
||||
|
||||
|
||||
Reference in new issue
Block a user