AWS host phase 2b: SSM port-forward transport to the agent, and a Fargate-sized chown #78

Merged
jknapp merged 4 commits from feat/ssm-transport into main 2026-11-14 23:16:55 +00:00
Owner

Phase 2b of the AWS host spec: the transport from the app to triple-c-agent in an ECS task. It also fixes an entrypoint problem that made Fargate starts take ~6½ minutes. Everything was tested against a real Fargate task in a throwaway account setup, since torn down.

What's in it

host/ssm.rs: one SSM port-forward (AWS-StartPortForwardingSession) per task, run through AWS's session-manager-plugin as a child process, the way the AWS CLI does it.

  • It is supervised: when a session drops it is reopened on the same loopback port, so an AgentEndpoint stays valid.
  • It ends every session it started (TerminateSession), including one whose open is cancelled partway.
  • Keepalive: an Up tunnel sends an unauthenticated GET /v1/health every 5 minutes. Session Manager cuts a silent tunnel at ~20 min, taking every open connection with it. The agent answers 401 before counting activity, so this won't defeat idle auto-stop.
  • Secrets: the session token reaches the plugin through its environment, never argv. The plugin's env is stripped of every inherited AWS_* variable, then given only the session's own credentials (for its ResumeSession), cached until 15 minutes before expiry.
  • EcsTask::resolve (via DescribeTasks) builds the SSM target and refuses tasks that aren't running or have no ECS Exec agent.
  • find_plugin looks in TRIPLEC_SSM_PLUGIN, then PATH, then the installer locations (the macOS GUI app gets no shell PATH).
  • Not wired into any app flow yet; phase 5 does that. The module carries a scoped allow(dead_code) until then.

Dependencies: aws-config, aws-sdk-ssm, aws-sdk-ecs. Phase 3 needs them anyway. Default features are off, so only the current TLS client is used, not the legacy rustls 0.21 / hyper 0.14 stack the defaults add. The tree keeps a single rustls.

entrypoint.sh: chown -R /home/claude became a find that chowns only what isn't already claude's.

  • On ECS, /home/claude is the image's own copy, so the blanket chown copied the whole tree up: ~5.5 min on 1 vCPU and ~2 GB of ephemeral storage.
  • With the fix, the agent answers 4 s after the container starts (83 s after RunTask).
  • Resulting ownership is identical to chown -R, symlinks included (checked in a container and in the task).

Measured on Fargate (us-west-2, 1 vCPU / 2 GB, plugin 1.2.896)

Open port forward 0.6-0.7 s
One-shot exec round trip 35 ms (vs ~1 s per execute-command)
Reconnect after the plugin is killed 0.55 s, same port
Throughput ~0.75 MB/s each way (SSM's limit)
Image pull (1.1 GB from public GHCR) 55 s
Silent tunnel, no keepalive cut at 19.7 min; held attach died
44 min with 22 silent, keepalive on no reconnect; held attach survived
ECS health check against the agent HEALTHY 105 s after RunTask
Background job across a kill -9'd tunnel kept running; a fresh tunnel found it

All of this, plus the health check and what survives the app quitting, is now in the spec (2026-11-13-aws-ecs-host-design.md: "To verify" and "Lifecycle and status").

Tests

  • 17 unit tests drive the supervisor with a fake plugin. They cover reconnecting on the same port, close and drop ending the session, giving up after the limit, the first open moving off a taken port, the token not being in argv, AWS_* stripping, the keepalive's shape and cadence, and a dropped open still sending TerminateSession (against a fake SSM endpoint; the test fails without the fix).
  • Ignored live tests in host/ssm/live_tests.rs (env in the file header): the operations through a real tunnel plus a forced drop, and a long hold with a silent stretch.
  • Full suite: 953 passed.

Review

An independent review found three issues:

  • A session leaked when close raced a reconnect: fixed.
  • Credentials were re-fetched on every reconnect, re-prompting MFA-style profiles: fixed.
  • Plugins can outlive the app: documented for phase 5. Measured: an orphaned session ends within 20 minutes, because the keepalive lives in the app.

Not in this PR

  • Bundling session-manager-plugin with the app (packaging).
  • The connection UI and permission check (phase 3).
  • Starting and stopping tasks, and Secrets Manager token delivery (phase 5).

🤖 Generated with Claude Code

Phase 2b of the AWS host spec: the transport from the app to `triple-c-agent` in an ECS task. It also fixes an entrypoint problem that made Fargate starts take ~6½ minutes. Everything was tested against a real Fargate task in a throwaway account setup, since torn down. ## What's in it **`host/ssm.rs`**: one SSM port-forward (`AWS-StartPortForwardingSession`) per task, run through AWS's `session-manager-plugin` as a child process, the way the AWS CLI does it. - It is **supervised**: when a session drops it is reopened **on the same loopback port**, so an `AgentEndpoint` stays valid. - It ends every session it started (`TerminateSession`), including one whose open is cancelled partway. - **Keepalive**: an Up tunnel sends an unauthenticated `GET /v1/health` every 5 minutes. Session Manager cuts a silent tunnel at ~20 min, taking every open connection with it. The agent answers 401 before counting activity, so this won't defeat idle auto-stop. - **Secrets**: the session token reaches the plugin through its environment, never argv. The plugin's env is stripped of every inherited `AWS_*` variable, then given only the session's own credentials (for its `ResumeSession`), cached until 15 minutes before expiry. - `EcsTask::resolve` (via `DescribeTasks`) builds the SSM target and refuses tasks that aren't running or have no ECS Exec agent. - `find_plugin` looks in `TRIPLEC_SSM_PLUGIN`, then `PATH`, then the installer locations (the macOS GUI app gets no shell `PATH`). - Not wired into any app flow yet; phase 5 does that. The module carries a scoped `allow(dead_code)` until then. **Dependencies:** `aws-config`, `aws-sdk-ssm`, `aws-sdk-ecs`. Phase 3 needs them anyway. Default features are off, so only the current TLS client is used, not the legacy rustls 0.21 / hyper 0.14 stack the defaults add. The tree keeps a single rustls. **`entrypoint.sh`**: `chown -R /home/claude` became a `find` that chowns only what isn't already `claude`'s. - On ECS, `/home/claude` is the image's own copy, so the blanket chown copied the whole tree up: ~5.5 min on 1 vCPU and ~2 GB of ephemeral storage. - With the fix, the agent answers 4 s after the container starts (83 s after `RunTask`). - Resulting ownership is identical to `chown -R`, symlinks included (checked in a container and in the task). ## Measured on Fargate (us-west-2, 1 vCPU / 2 GB, plugin 1.2.896) | | | |---|---| | Open port forward | 0.6-0.7 s | | One-shot exec round trip | 35 ms (vs ~1 s per `execute-command`) | | Reconnect after the plugin is killed | 0.55 s, same port | | Throughput | ~0.75 MB/s each way (SSM's limit) | | Image pull (1.1 GB from public GHCR) | 55 s | | Silent tunnel, no keepalive | cut at 19.7 min; held attach died | | 44 min with 22 silent, keepalive on | no reconnect; held attach survived | | ECS health check against the agent | `HEALTHY` 105 s after `RunTask` | | Background job across a `kill -9`'d tunnel | kept running; a fresh tunnel found it | All of this, plus the health check and what survives the app quitting, is now in the spec (`2026-11-13-aws-ecs-host-design.md`: "To verify" and "Lifecycle and status"). ## Tests - 17 unit tests drive the supervisor with a fake plugin. They cover reconnecting on the same port, close and drop ending the session, giving up after the limit, the first open moving off a taken port, the token not being in argv, `AWS_*` stripping, the keepalive's shape and cadence, and a dropped open still sending `TerminateSession` (against a fake SSM endpoint; the test fails without the fix). - Ignored live tests in `host/ssm/live_tests.rs` (env in the file header): the operations through a real tunnel plus a forced drop, and a long hold with a silent stretch. - Full suite: 953 passed. ## Review An independent review found three issues: - A session leaked when close raced a reconnect: fixed. - Credentials were re-fetched on every reconnect, re-prompting MFA-style profiles: fixed. - Plugins can outlive the app: documented for phase 5. Measured: an orphaned session ends within 20 minutes, because the keepalive lives in the app. ## Not in this PR - Bundling `session-manager-plugin` with the app (packaging). - The connection UI and permission check (phase 3). - Starting and stopping tasks, and Secrets Manager token delivery (phase 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp added 4 commits 2026-11-14 20:10:29 +00:00
host::ssm opens one AWS-StartPortForwardingSession to triple-c-agent in an
ECS task by running session-manager-plugin, supervises it, and reopens it
on the same loopback port when it drops, so an AgentEndpoint stays valid.
The StartSession token reaches the plugin through its environment, never
argv; the plugin gets the caller's credentials (and no other AWS_* vars)
so it can resume its own data channel. Unit tests drive the supervisor
with a fake plugin; ignored live tests run against a real task.

entrypoint.sh: chown only what is not already claude's. On Fargate,
/home/claude is the image's own copy, and chown -R copied all of it up:
the agent came up ~5.5 min after the container started; now 4 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes. A StartSession whose open future is dropped (a close racing a
reconnect, a caller timing out the first open) is now ended by a guard
instead of left to SSM's idle timeout. The plugin's credentials are cached
until 15 minutes before expiry, so a prompting credential_process is not
asked again on every reconnect. Fake-plugin tests run the script through
/bin/sh, so a parallel test's fork cannot fail the spawn with ETXTBSY.
CLAUDE.md: host/ssm.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured: a silent port forward was cut at 19.7 minutes ("timed out due to
inactivity"), taking every connection through it with it. Every 5 minutes
the supervisor now sends an unauthenticated GET /v1/health through the
tunnel; the agent answers 401 before counting activity, so this keeps the
session alive without defeating idle auto-stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AWS spec: phase 2b measurements (SSM, Fargate start, health, re-attach)
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (push) Successful in 7s
Secret Scan / scan (pull_request) Successful in 10s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build Container / build-container (pull_request) Successful in 2m25s
Build App (Preview) / build-macos (pull_request) Successful in 4m36s
Build App (Preview) / build-windows (pull_request) Successful in 9m15s
Build App (Preview) / test (pull_request) Successful in 10m59s
Build App (Preview) / build-linux (pull_request) Successful in 15m27s
Build App (Preview) / prune-previews (pull_request) Successful in 5s
7462cb05ec
Session stability, idle timeout and the keepalive that defeats it, Fargate
pull and start times with the chown fix, the ECS health check against the
agent, and what survives the app quitting. The SSM supervisor's drop reason
now carries the plugin's exit status as well as its last output line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jknapp merged commit 95991c1309 into main 2026-11-14 23:16:55 +00:00
jknapp deleted branch feat/ssm-transport 2026-11-14 23:16:59 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: CyberCoveLLC/Triple-C#78